/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is dual-licensed under either the MIT license found in the
 * LICENSE-MIT file in the root directory of this source tree or the Apache
 * License, Version 2.0 found in the LICENSE-APACHE file in the root directory
 * of this source tree. You may select, at your option, one of the
 * above-listed licenses.
 */

#![feature(error_generic_member_access)]

use std::ffi::OsStr;
use std::fmt;
use std::path::Path;

use buck2_core::fs::project_rel_path::ProjectRelativePathBuf;
use buck2_error::BuckErrorContext;
use buck2_fs::fs_util;
use buck2_fs::paths::abs_norm_path::AbsNormPathBuf;
use buck2_fs::paths::abs_path::AbsPathBuf;
use buck2_fs::paths::forward_rel_path::ForwardRelativePathBuf;
// Note: Using this because we don't need to propagate async in the offline
// archiver program
use buck2_util::process::background_command;

#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct RelativeSymlink {
    pub link: ProjectRelativePathBuf,
    pub target: ProjectRelativePathBuf,
}

#[derive(Clone, Debug, PartialEq)]
pub struct AbsoluteSymlink {
    pub link: AbsPathBuf,
    pub target: AbsNormPathBuf,
}

#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ExternalSymlink {
    pub link: ProjectRelativePathBuf,
    pub target: AbsPathBuf,
    pub remaining_path: Option<ForwardRelativePathBuf>,
}

impl ExternalSymlink {
    pub fn full_target(&self) -> AbsPathBuf {
        if let Some(remaining_path) = &self.remaining_path {
            self.target.join(remaining_path.as_path())
        } else {
            self.target.clone()
        }
    }

    /// ExternalSymlink "remaining_path"s can themselves contain symlinks. When
    /// assembling the final archive, we need to preserve these interior links
    /// and materialize their targets in the offline archive.
    pub fn interior_links(&self) -> buck2_error::Result<Vec<AbsoluteSymlink>> {
        let mut targets = Vec::new();
        if let Some(remaining_path) = &self.remaining_path {
            for ancestor in remaining_path.as_path().ancestors() {
                let path = self.target.join(ancestor);
                if let Some(meta) = fs_util::symlink_metadata_if_exists(&path)? {
                    if meta.file_type().is_symlink() {
                        let target = fs_util::canonicalize(&path)?;
                        targets.push(AbsoluteSymlink { link: path, target });
                    }
                }
            }
        }

        Ok(targets)
    }
}

/// Structured format for an "offline archive manifest", which contains information
/// necessary to perform a fully offline build of a particular target.
///
/// This manifest is generated by running:
///   `buck2 debug io-trace export-manifest`
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OfflineArchiveManifest {
    /// The repository revision this archive was generated from.
    pub repository: RepositoryMetadata,
    /// List of project-relative paths that are required to perform a build.
    pub paths: Vec<ProjectRelativePathBuf>,
    /// List of external, absolute paths required to perform a build.
    pub external_paths: Vec<AbsNormPathBuf>,
    /// List of project-relative symlinks with targets inside the project.
    pub relative_symlinks: Vec<RelativeSymlink>,
    /// List of project-relative symlinks with targets outside the project.
    pub external_symlinks: Vec<ExternalSymlink>,
}

/// Repository information for an "offline archive manifest". Contains metadata
/// about the repository this manifest was generated from.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RepositoryMetadata {
    /// Repository revision this manifest came from.
    pub revision: String,
    /// Repository name.
    pub name: String,
}

impl RepositoryMetadata {
    pub fn from_cwd() -> buck2_error::Result<Self> {
        Self::from_path(
            std::env::current_dir().buck_error_context("Error getting current directory")?,
        )
    }

    pub fn from_path<P: AsRef<Path>>(path: P) -> buck2_error::Result<Self> {
        let revision = hg_in(&path, ["whereami"])?;
        let name = hg_in(path, ["config", "remotefilelog.reponame"])?;
        Ok(Self { revision, name })
    }
}

impl fmt::Display for RepositoryMetadata {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}@{}", self.name, self.revision)
    }
}

pub fn hg<I, S>(args: I) -> buck2_error::Result<String>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    hg_in(std::env::current_dir()?, args)
}

fn hg_in<I, S, P>(path: P, args: I) -> buck2_error::Result<String>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
    P: AsRef<Path>,
{
    let mut cmd = background_command("hg");
    let cmd = cmd
        .args(args)
        .current_dir(path.as_ref())
        .env("HGPLAIN", "1");

    let result = cmd
        .output()
        .buck_error_context("failed to dispatch hg command")?;
    if result.status.success() {
        let out = String::from_utf8(result.stdout).buck_error_context("hg stdout to string")?;
        let out = out.trim();
        if out.is_empty() {
            Err(buck2_error::buck2_error!(
                buck2_error::ErrorTag::OfflineArchive,
                "expected output from `{:?}`",
                cmd
            ))
        } else {
            Ok(out.to_owned())
        }
    } else {
        let err = String::from_utf8(result.stderr).buck_error_context("hg stderr to string")?;
        Err(buck2_error::buck2_error!(
            buck2_error::ErrorTag::OfflineArchive,
            "{}",
            err
        ))
    }
}

/// TODO(skarlage): Symlinks are weird on Windows; this crate won't actually
/// be used for windows for now.
#[cfg(all(test, not(windows)))]
mod tests {
    use buck2_fs::paths::abs_path::AbsPath;
    use buck2_fs::paths::forward_rel_path::ForwardRelativePath;
    use tempfile::TempDir;

    use super::*;

    struct Symlink {
        pub link: &'static str,
        pub target: &'static str,
    }

    enum Entry {
        File(&'static str),
        Dir(&'static str),
        RelativeSymlink(Symlink),
        /// Separate so we can make 'target' absolute, which needs tree root.
        AbsoluteSymlink(Symlink),
    }

    /// Creates a tree of entries rooted at
    fn create_tree(entries: Vec<Entry>) -> buck2_error::Result<TempDir> {
        let working_dir = TempDir::new()?;
        let abs = AbsPath::new(working_dir.path())?;
        for entry in entries {
            match entry {
                Entry::File(path) => {
                    fs_util::create_file(abs.join(path))?;
                }
                Entry::Dir(dir) => {
                    fs_util::create_dir_all(abs.join(dir))?;
                }
                Entry::RelativeSymlink(symlink) => {
                    fs_util::symlink(symlink.target, abs.join(symlink.link))?;
                }
                Entry::AbsoluteSymlink(symlink) => {
                    fs_util::symlink(
                        working_dir.path().join(symlink.target),
                        abs.join(symlink.link),
                    )?;
                }
            }
        }

        Ok(working_dir)
    }

    #[test]
    fn test_full_target_no_remaining() -> buck2_error::Result<()> {
        let external_link = ExternalSymlink {
            link: ProjectRelativePathBuf::unchecked_new("foo/bar/baz".to_owned()),
            target: AbsPathBuf::new("/some/absolute/path")?,
            remaining_path: None,
        };
        assert_eq!(
            AbsPathBuf::new("/some/absolute/path")?,
            external_link.full_target()
        );
        Ok(())
    }

    #[test]
    fn test_full_target_with_remaining() -> buck2_error::Result<()> {
        let external_link = ExternalSymlink {
            link: ProjectRelativePathBuf::unchecked_new("foo/bar/baz".to_owned()),
            target: AbsPathBuf::new("/some/absolute/path")?,
            remaining_path: Some(ForwardRelativePathBuf::new("and/some/extra".to_owned())?),
        };
        assert_eq!(
            AbsPathBuf::new("/some/absolute/path/and/some/extra")?,
            external_link.full_target()
        );
        Ok(())
    }

    #[test]
    fn test_interior_links_no_remaining() -> buck2_error::Result<()> {
        let external_link = ExternalSymlink {
            link: ProjectRelativePathBuf::unchecked_new("foo/bar/baz".to_owned()),
            target: AbsPathBuf::new("/some/absolute/path")?,
            remaining_path: None,
        };
        assert!(external_link.interior_links()?.is_empty());
        Ok(())
    }

    #[test]
    fn test_interior_links_relative() -> buck2_error::Result<()> {
        let tree = create_tree(vec![
            Entry::Dir("foo/bar/baz"),
            Entry::File("foo/bar/stuff.txt"),
            Entry::RelativeSymlink(Symlink {
                link: "foo/bar/baz/link",
                target: "..",
            }),
        ])?;

        // Canonicalize this because the canonicalize() call in `interior_links()`
        // resolves to /private/var/... on macOS.
        let working_dir = AbsNormPathBuf::new(tree.path().canonicalize()?)?;

        let external_link = ExternalSymlink {
            link: ProjectRelativePathBuf::unchecked_new("unused".to_owned()),
            target: working_dir
                .join(ForwardRelativePath::unchecked_new("foo/bar/baz"))
                .as_abs_path()
                .to_owned(),
            remaining_path: Some(ForwardRelativePathBuf::new("link/stuff.txt".to_owned())?),
        };

        assert_eq!(
            vec![AbsoluteSymlink {
                link: AbsPathBuf::new(
                    working_dir.join(ForwardRelativePath::unchecked_new("foo/bar/baz/link"))
                )?,
                target: working_dir.join(ForwardRelativePath::unchecked_new("foo/bar")),
            }],
            external_link.interior_links()?
        );

        Ok(())
    }

    #[test]
    fn test_interior_links_absolute() -> buck2_error::Result<()> {
        let tree = create_tree(vec![
            Entry::Dir("foo/bar/baz"),
            Entry::File("foo/bar/stuff.txt"),
            Entry::AbsoluteSymlink(Symlink {
                link: "foo/bar/baz/link",
                target: "foo/bar",
            }),
        ])?;

        // Canonicalize this because the canonicalize() call in `interior_links()`
        // resolves to /private/var/... on macOS.
        let working_dir = AbsNormPathBuf::new(tree.path().canonicalize()?)?;

        let external_link = ExternalSymlink {
            link: ProjectRelativePathBuf::unchecked_new("unused".to_owned()),
            target: working_dir
                .join(ForwardRelativePath::unchecked_new("foo/bar/baz"))
                .as_abs_path()
                .to_owned(),
            remaining_path: Some(ForwardRelativePathBuf::new("link/stuff.txt".to_owned())?),
        };

        assert_eq!(
            vec![AbsoluteSymlink {
                link: AbsPathBuf::new(
                    working_dir.join(ForwardRelativePath::unchecked_new("foo/bar/baz/link"))
                )?,
                target: working_dir.join(ForwardRelativePath::unchecked_new("foo/bar")),
            }],
            external_link.interior_links()?
        );

        Ok(())
    }
}
